Webブラウザ上でストリーミングしながら暗号化・復号したい - Firefox Sendのストリーム暗号化の裏側を活用する
何がしたいか?
ReadableStreamを暗号化したい。全部BlobとかArrayBufferとかUint8Arrayしたりせず、ストリームのまま暗号化したい。
なぜストリーミングしながら暗号化したいか?
大きなデータや終わりが決まらないデータに対して暗号化したいから。
終わりの決まらないデータの具体例は、通話の音声ストリームなど。
GitHubリポジトリ
https://gh-card.dev/repos/mozilla/send.svg https://github.com/mozilla/send
前置き:ブラウザのエンドツーエンド暗号化事情とか
こうなると、サードパーティ製の暗号化ライブラリを探すか、自前でゴリゴリReadableStreamを暗号化・復号するプログラムを書くしかなさそう。サードパーティ製ライブラリは今の所なさそうだった。Node.jsでは標準で提供される機能であるためか、見つからなかった。自前で書くのはセキュリティにものすごく触れるところなので、ちゃんとアルゴリズムを勉強する必要があるのと、僕以外の第三者が使うときに信頼してもらうのが難しくなるのかな?などの懸念点があった。
すこし前にFirefox Sendが登場したことで話題になり、エンドツーエンドで暗号化していることを知り実装がとても気になっていた。そこでGitHubリポジトリのソースのどの場所がストリーミングしながら暗号化・復号を行っているのか調べることにした。 まず、公式の暗号化の説明として以下の文書が参考になる。
この文書によると、ファイルはAES-GCMで暗号化される。文書途中に出てくるECEは、「Encrypted Content-Encoding」の略だと思われる。ECEは後でファイル名としても出てくる。
フロントのコードはapp/ディレクトリにある様子。
色々調べた結果、以下の行のapp/ece.jsにあるencryptStream()とdecryptStream()があれば、ReadableStreamを暗号化・復号できる。
そして、app/ece.jsはapp/streams.jsを利用している。そのためたくさんファイルはあったが、ストリーミングしながら暗号化するのに必要だったのは
app/ece.js
app/streams.js
であった。
GitHubリポジトリ
元々の作者さんの実装の努力は尊重したかった。そこでgit filter-branchなどを使い貢献者さんのコミットは残すように心がけた。コピペしてあたかも自分がすべて作ったかのようなことにするのは避けたかった。
https://gh-card.dev/repos/nwtgck/aes128gcm-stream-npm.svg https://github.com/nwtgck/aes128gcm-stream-npm
使い方
以下のようにReadableStreamを暗号化、復号できる。
code:typescript
// Import
import {encryptStream, decryptStream} from 'aes128gcm-stream';
// Create a simple readable
// e.g. (await fetch("...")).body is ReadableStream
const readableStream: ReadableStream<Uint8Array> = ...
// Generate random key
const key: Uint8Array = crypto.getRandomValues(new Uint8Array(16));
// Encrypt
const encryptedStream: ReadableStream<Uint8Array> = encryptStream(
readableStream,
key,
);
// Decrypt
const decryptedStream: ReadableStream<Uint8Array> = decryptStream(
encryptedStream,
key
);
注意点は暗号化・復号がTransformStreamとして提供されているわけではなく、暗号化したいReadableStreamを関数に渡すと暗号化されたReadableStreamが得られるという実装になっている。実装は元々のFirefox Sendからいじってはない。 他のTypeScriptのプロジェクトとの連携などを考え、TypeScriptにするために型を書いていったという感じである。 型定義を別途することも考えたが、実装と結びついていない型定義よりも実装に結びつくべきだと思うので、こういう方法をとった。これのデメリットして本家のFirefox Sendの実装に追従するときに面倒になることが考えられる。ただソースコードが2ファイルで行数も多くなく、TypeScriptとJavaScriptは型定義がある・ない意外はとてもdiffが近い。そのため今回はTypeScriptとして管理することに決めた。あと、コードが初見でも読みやすいようにできていた。
テストまわり
個人的に苦労したのはテストの環境を整えること。
JavaScript関連はサーバーサイドNode.jsばかりだったため、ブラウザ特有のTextEncoderとかReadableStreamを使うテスト行うのが初めてだった。
ローカルだけのテストでは、使う人にとってライブラリがちゃんと動くのか説得力がない。
テスト環境があらゆる場所で構築できることを示すためにもCIがほしい。
そのためCIでも動くテスト環境の構築が条件。
なるべくなら入り組んだことをせず、ストレートにテストをしたかった。
なぜならテストしたいことはとても明白な内容。「暗号化できるか?」や、「暗号化したものを復号すれば元データと一致するか?」ぐらいである。このプロジェクトに「ボタンをクリックしたとき何かが表示される」のようなUIのテストが入る余地は将来的にない。関数の実行結果をassertするストレートなテストが書きたい。
Firefox Sendのフロントエンドのテストを見ると、expressサーバー立ててそこでMochaを書かれたHTMLをホストして、 puppeteerを使ってそのページに訪れるようなことをやっている。とてもきれい書かれている。
なるべくならexpressとかHTMLに考えずにできるものを探したかった。
以下が実際のテストのコード。
expressとかpuppeteerは登場しなくてとてもシンプルでストレートなテスト。
少しnpm i -Dでパッケージをインストールして、karma.conf.js書いて、npmスクリプトに"test": "karma start"するだけで動いてくれる。